fix(plugin-detail): copy object-valued cells as JSON, not [object Object] - #8460
Conversation
…ject]` `DetailSection`'s click-to-copy built its payload with `String(value)`, and `String()` of an object is the literal text `[object Object]`. The affordance is offered for every non-empty value — objects included — so a reader clicking the copy button, the row, or pressing Enter on an address, geolocation, JSON, file, expanded-lookup, repeater or image cell silently got a placeholder on the clipboard, while the cell beside the button rendered that same value correctly. Objects are now serialized with `JSON.stringify`. Non-objects keep `String()` byte-for-byte: a number still copies `16` (not the rendered `16.00`), a percent `0.123` (not `12%`), a date `2026-03-04` (not `Mar 4`). Copying the RENDERED text was measured and rejected — it is the worse contract for 9 of 17 field types and loses data silently (an `image` or `boolean` cell renders no text at all, so it would copy the empty string). The JSON blob is a defensible default, not a settled contract; the read site says so in place. Card: objectui#8395 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
|
PM contract review — accepted, flipped to ready, auto-merge armed. All three open questions: A, A, A. None of them needs the maintainer. Q1 — multiselect moves: A, as shipped
The deciding argument is yours, not mine: the old Q2 — the try/catch: A, keep itA bare Q3 — nine payloads, not seven: A, pin all nineB would ship two behaviour changes with no pin, and — as you say — image is precisely the row an option-A rewrite would turn into the empty string. That row is the regression guard for the option I rejected. Pinning it is the point. ⭐ And this exposes a real limitation in my control rule. I wrote that "every object row must also assert the cell rendered its value". The image cell renders an What the pin does that mattersThe scalar half asserting the rendered text beside the payload is the load-bearing part: it makes this file go red for any later "copy the rendered text" rewrite, which is the option my own earlier brief preferred and your previous run's measurement falsified. That is the durable outcome of this card — not the Two other choices I want on the record as right:
Scope disciplineobjectui#8440's password/secret branch byte-for-byte untouched with the pin deliberately asserting nothing about it, and the read-site docblock naming the JSON blob as a defensible default, not a settled contract with option B cited — both exactly as ruled. Not pinning objectui#8441's Noted for the next reader, since you recorded it rather than quietly fixing it: your first address fixture spelled Generated by Claude Code |
Fixes #8395
DetailSection's click-to-copy built its payload withString(value), andString()of an object is the literal text[object Object]. The affordance is offered for every non-empty value — objects included (canCopyishasCellValue, objectui#8376) — so a reader clicking the copy button, the row, or pressing Enter on an address, geolocation, JSON, file, expanded-lookup, repeater or image cell silently got a placeholder on the clipboard while the cell beside the button rendered that same value correctly. Nothing errors; it is noticed only on paste.The change is one read site: objects are serialized with
JSON.stringify, everything else keepsString().Per-kind payloads — measured, not reasoned
Rendered via a real
DetailSectionatinnerWidth1280; the observed value is the ARGUMENT handed tonavigator.clipboard.writeText.1 Main St, Springfield, IL 62704, USA[object Object]{"street":"1 Main St","city":"Springfield","state":"IL","postalCode":"62704","country":"USA"}30.2742, 120.1551[object Object]{"latitude":30.2741567,"longitude":120.1551234}{"a":1,"b":["x","y"]}[object Object]{"a":1,"b":["x","y"]}contract.pdf[object Object]{"name":"contract.pdf","url":"https://cdn.example.com/contract.pdf"}Acme Corp[object Object]{"id":"acct-1","name":"Acme Corp"}2 files[object Object],[object Object][{"name":"a.pdf"},{"name":"b.pdf"}][object Object],[object Object][{"label":"one"},{"label":"two"}]imgelement, no text[object Object]{"name":"logo.png","url":"https://cdn.example.com/logo.png"}AlphaBetaalpha,beta["alpha","beta"]['alpha','beta']now copies as a JSON array. The new form is lossless where the old one was ambiguous for any value containing a comma. It is called out here rather than buried: it is the only row whose BEFORE column was not already a placeholder.⭐ The non-regression half
Every correct-today scalar payload is byte-identical, and the pin asserts the rendered text beside it, so it is red for any later "just copy what the cell renders" rewrite:
16.00161,234.501234.512%0.123Mar 42026-03-04Closed WonwonPlain String ValuePlain String ValuetrueWhat this deliberately does NOT decide
formatAddress, objectui#4037) — as where the real per-kind contract gets decided. That needs product answers that do not exist yet (address under which locale, geolocation at stored precision or displayed rounding, json compact or pretty, file filename or list or URL, multiselect labels or values, lookup name or id, image URL or filename) and is a separate card.daterendersMar 4(the year is gone),percentrenders12%against a stored0.123(a different quantity),datetimeconcatenates to an unparseable3/4/20265:06 am,imageandbooleanrender no text at all — so those two would copy the EMPTY STRING, an implementation strictly worse than the defect. It would also make the payload display-locale dependent.canCopyaway fromhasCellValue, whose three readers must agree per objectui#8376's docblock.password/secretbranch of this same handler (copy writes the raw secret while the cell renders the mask) is in the maintainer's decision box. The pin asserts nothing about it on purpose, so whichever way that card is answered, no case in this file turns red. That card remains open.The one addition beyond the literal one-liner
JSON.stringifythrows on a cycle, and this runs inside a click handler. The stringify is therefore wrapped in the SAMEtry/catchthatJsonCellRendereralready applies to this exact operation on this exact value, falling back to today's string form. It is pinned (a self-referencing value still copies[object Object]and the click does not throw) and is the only input for which that text is still written. Strike it if you would rather have the bare call.Ablation — proof the pin can fail
Run from the committed implementation, restoring the copy handler's bare⚠️ There is a SECOND
String(value).String(value)in this file —displayValue's no-renderer fallback — which is NOT this defect; the mutation is anchored on the handler body and that second site is asserted unmoved.The nine red rows, by name — every one an object-valued kind, each failing on its payload (
Received: "[object Object]") while its rendered-value CONTROL still passed, so the failure is about what was copied and never about a blank cell:The eight that stayed green under the mutation are exactly the ones that must: the six scalars, the boolean, and the unserializable-value case.
Verification
pnpm exec vitest run packages/plugin-detail/src/__tests__/DetailSection.copyObjectValues-8395.test.tsx—Tests 17 passed (17), lockVERDICT command-exit 0pnpm exec vitest run packages/plugin-detail/—Test Files 138 passed (138),Tests 1248 passed (1248), lockVERDICT command-exit 0pnpm --filter @object-ui/plugin-detail type-check(tsc --noEmit && tsc -p tsconfig.test.json) — lockVERDICT command-exit 0eslint .inpackages/plugin-detail— exit 0,901 problems (0 errors, 901 warnings), identical to the pre-change baseline and none in the changed filescheck-governed-queue-guard --teston all three changed paths —NOT GOVERNED — 3 path(s) checked against 5 governed surface(s); none matched.grep -naPover the changed files: zero hits, with a lit control that firesNo downstream package asserts this payload: of the nine test files in the repo that touch
clipboard, the only two that render this surface are inpackages/plugin-detail.Changeset
minor, on@object-ui/plugin-detail— this moves a shipped clipboard payload, which is user-visible behaviour; the landed precedent for arecord:detailsbehaviour change is PR #8396. The repo forbidsmajor. Gate verdicts, quoted:Generated by Claude Code